When building AWS Lambda functions, managing concerns like input validation, error handling, and logging can quickly become repetitive and cumbersome. A middleware-based workflow simplifies this by promoting code reuse and separation of concerns, making Lambdas more maintainable and scalable.
Using TypeScript
for AWS Lambda
provides type safety, reducing runtime errors and improving developer productivity with better tooling and auto-completion. It ensures that your code is more robust, self-documenting, and easier to refactor as your application grows.
One crucial aspect of a middleware-based workflow is schema validation. By validating incoming requests before processing them, we can prevent unexpected errors, enforce data integrity, and improve security. Leveraging @raminyavari/ajv-ts-schema
with @middy
, we can seamlessly integrate schema validation into our Lambda middleware, ensuring type safety and runtime validation work hand in hand.
In this article, we’ll explore how to implement a middleware-based AWS Lambda in TypeScript, combining @raminyavari/ajv-ts-schema
for schema validation and @middy
for middleware management. Let’s dive in!
We'll explore three approaches to building AWS Lambda
functions in TypeScrip
t:
@middy
for middleware support.
@middy
with @raminyavari/ajv-ts-schema
for robust schema validation.
This progression will highlight the benefits of middleware and schema validation in making Lambdas more maintainable, scalable, and secure.
In our sample Lambda function, the input is a user object containing basic user information and a list of orders. Since the event payload can be unpredictable, we must carefully validate it and handle errors appropriately—returning a 400 Bad Request
for invalid input or a 500 Internal Server Error
for unexpected failures.
Currently, both validation
and error handling
are tightly coupled within the Lambda function itself, making the code more complex and harder to maintain.
Our goal is to separate these concerns using middleware, allowing us to shrink the handler to just a few lines—focused solely on business logic—while offloading validation and error handling to reusable components.
Let’s start with the first scenario. Below is the Lambda function:
export const handler = async (event: APIGatewayEvent): Promise<APIGatewayProxyResult> => {
try {
// Parse and validate the event body
const body = event.body ? JSON.parse(event.body) : {};
const validatedEvent = validateEvent(body);
const result = await processRequest(validatedEvent);
return {
statusCode: 200,
body: result,
};
} catch (error) {
if (error instanceof ValidationError) {
return {
statusCode: 400,
body: JSON.stringify({ error: error.message }),
};
}
return {
statusCode: 500,
body: JSON.stringify({ error: "Internal server error" }),
};
}
};
Next, we define the types:
interface Order {
productId: string;
quantity: number;
}
interface Address {
street: string;
city: string;
postalCode: string;
}
interface UserEvent {
userId: string;
email: string; // Should verify it is an email
age: number; // Should be an integer between 18 and 100
address: Address;
orders: Order[];
}
Finally, we define a function along with a helper class to handle the validation:
// Custom error class for validation errors
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
// Schema validation function
function validateEvent(event: any): UserEvent {
if (typeof event !== "object" || event === null) {
throw new ValidationError("Event must be a valid JSON object.");
}
if (typeof event.userId !== "string" || !event.userId.trim()) {
throw new ValidationError("userId must be a non-empty string.");
}
if (typeof event.email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(event.email)) {
throw new ValidationError("email must be a valid email address.");
}
if (typeof event.age !== "number" || event.age < 18 || event.age > 100) {
throw new ValidationError("age must be a number between 18 and 100.");
}
if (
typeof event.address !== "object" ||
typeof event.address.street !== "string" ||
typeof event.address.city !== "string" ||
typeof event.address.postalCode !== "string"
) {
throw new ValidationError("address must contain valid street, city, and postalCode.");
}
if (!Array.isArray(event.orders) || event.orders.length === 0) {
throw new ValidationError("orders must be a non-empty array.");
}
for (const order of event.orders) {
if (typeof order.productId !== "string" || !order.productId.trim()) {
throw new ValidationError("Each order must have a valid productId.");
}
if (typeof order.quantity !== "number" || order.quantity <= 0) {
throw new ValidationError("Each order must have a quantity greater than 0.");
}
}
return event as UserEvent;
}
The drawbacks of this approach are:
Manual Schema Validation Is Tedious and Error-Prone
Error Messages Are Not Standardized
Inconsistent Type Safety
UserEvent
type.
event
as UserEvent
, which is unsafe if validation misses something.
No Re-usability of Validation Logic
Error Handling Could Be Improved
ValidationError
class is custom but doesn’t provide detailed context.
400
response, but finer-grained error responses (e.g., missing vs. invalid fields) could be helpful.
@middy
Middy is a lightweight, middleware engine for AWS Lambda
functions. It allows you to easily add reusable logic to Lambda functions without modifying the core business logic. You can apply middleware to handle things like input validation, error handling, logging, or request transformation. Middy follows the middleware pattern, making it simple to compose multiple functionalities in a clean and modular way, enhancing Lambda functions' flexibility and maintainability.
Middy is particularly useful for improving Lambda performance by separating concerns and ensuring consistent behavior across multiple functions. It is designed for Lambda
with API Gateway
, assuming middleware compliance with API calls that include a body. First, we'll examine it in this context before exploring its use with AppSync
and GraphQL
.
Now, let's see how the code looks when we use @middy
.
As shown in the snippet below, the Lambda function becomes much simpler, focusing solely on the core business logic while offloading other concerns like validation and error handling to middleware.
const baseHandler = async async (event: UserEvent) => {
return await processRequest(event);
};
export const handler = middify({ handler: baseHandler, schema: userSchema});
With this approach, each Lambda function becomes as simple as shown above. We only need to implement a middify
function once, which takes a Lambda function and its corresponding schema, and returns an enhanced version of the Lambda function with validation
and error handling
handled automatically.
Let’s take a look at the implementation:
import middy from '@middy/core';
import jsonBodyParser from '@middy/http-json-body-parser';
import httpErrorHandler from '@middy/http-error-handler';
import validator from '@middy/validator';
import { transpileSchema } from '@middy/validator/transpile';
export const middify = ({ handler, schema }: { handler: unknown; schema: any }) => {
const handlerWrapper = async (event: unknown, context: unknown) => {
const result = await handler(event, context);
return {
statusCode: 200,
body: typeof result === "string" ? result : JSON.stringify(result),
};
};
return middy()
.use(jsonBodyParser())
.use(validator({ eventSchema: transpileSchema(schema) }))
.use(httpErrorHandler())
.handler(handlerWrapper);
};
When using @middy
for schema validation, the schema must conform to the AJV
standard (Learn more about AJV).
Based on our example and the AJV
specifications, the schema would look like this:
const userSchema = {
type: "object",
required: ["userId", "email", "age", "address", "orders"],
properties: {
userId: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
age: { type: "integer", minimum: 18, maximum: 100 },
address: {
type: "object",
required: ["street", "city", "postalCode"],
properties: {
street: { type: "string" },
city: { type: "string" },
postalCode: { type: "string" },
},
},
orders: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["productId", "quantity"],
properties: {
productId: { type: "string", minLength: 1 },
quantity: { type: "number", minimum: 1 },
},
},
},
},
};
With this approach, we still need to define our types separately, as the AJV
schema is simply a JSON
object, not a type definition.
Therefore, we will define the types as follows:
interface UserEvent {
// Properties are exactly the same as the first scenario
// So, not repeating them here
}
This approach also has some drawbacks:
schema
and types
for each Lambda function.
schema
and type definitions are always in sync. When one changes, the other must be updated, which can be cumbersome and error-prone.
AJV
schema manually is not type-safe, and there’s a risk of introducing issues. While some packages allow you to define the schema in TypeScript
using decorators, they can still be error-prone and require recompilation of the schema after every change.
To address these issues, we will use the @raminyavari/ajv-ts-schema
package, which provides a more efficient solution.
@raminyavari/ajv-ts-schema
By using @raminyavari/ajv-ts-schema
, the schema and type definition are combined. This approach offers several advantages:
Let’s take a look at the handler:
import { type AjvJsonSchema } from '@raminyavari/ajv-ts-schema';
const baseHandler: LambdaHandler<AjvJsonSchema<UserSchema>, unknown> = async (event) => {
return await processRequest(event);
};
export const handler = middify({ handler: addTenant, schema: UserSchema });
To improve type safety, we've define a new type as follows:
type LambdaHandler<Event = unknown, Context = unknown> = (
event: Event,
context: Context
) => Promise<string | object | unknown>;
The middify
function remains largely the same, with a small change: the type of the schema
has been updated from any
to typeof AjvSchema
. All schemas must now extend the AjvSchema
class. With this update, the middify
function looks like this:
import middy from '@middy/core';
import jsonBodyParser from '@middy/http-json-body-parser';
import httpErrorHandler from '@middy/http-error-handler';
import validator from '@middy/validator';
import { transpileSchema } from '@middy/validator/transpile';
import { AjvSchema } from '@raminyavari/ajv-ts-schema';
export const middify = ({ handler, schema }: {
handler: unknown;
schema: typeof AjvSchema
}) => {
const ajvSchema = schema.getSchema();
const handlerWrapper = async (event: unknown, context: unknown) => {
const result = await (handler as LambdaHandler)(event, context);
return {
statusCode: 200,
body: typeof result === "string" ? result : JSON.stringify(result),
};
};
return middy()
.use(jsonBodyParser())
.use(validator({ eventSchema: transpileSchema(ajvSchema) }))
.use(httpErrorHandler())
.handler(handlerWrapper);
};
The most powerful part of this approach is that we can define both the schema and the event type simultaneously.
import {
AjvSchema,
AjvObject,
AjvProperty,
} from '@raminyavari/ajv-ts-schema';
@AjvObject()
class Order extends AjvSchema {
@AjvProperty({ type: "string", minLength: 1, required: true })
productId!: string;
@AjvProperty({ type: "number", minimum: 1, required: true })
quantity!: number;
}
@AjvObject()
class Address extends AjvSchema {
@AjvProperty({ type: "string", required: true })
street!: string;
@AjvProperty({ type: "string", required: true })
city!: string;
@AjvProperty({ type: "string", required: true })
postalCode!: string;
}
@AjvObject()
class UserEvent extends AjvSchema {
@AjvProperty({ type: "string", minLength: 1, required: true })
userId!: string;
@AjvProperty({ type: "formatted-string", format: "email", required: true })
email!: string;
@AjvProperty({ type: "integer", minimum: 18, maximum: 100, required: true })
age!: number;
@AjvProperty(Address)
address!: Address;
@AjvProperty({ type: "array", items: Order, minItems: 1 })
orders!: Order[];
}
With this approach, we gain the following benefits:
@middy
within our middify
function.
AppSync
and GraphQL
When our Lambda is invoked by GraphQL
through AppSync
, everything remains the same except for the middify
function.
In this case, two adjustments are needed:
jsonBodyParser
middleware assumes the event contains a body, which isn’t the case with AppSync
. We need to create a custom middleware.
httpErrorHandler
middleware returns HTTP errors, whereas GraphQL
expects a different error format.
With these changes, our function will look like this:
import middy from '@middy/core';
import jsonBodyParser from '@middy/http-json-body-parser';
import httpErrorHandler from '@middy/http-error-handler';
import validator from '@middy/validator';
import { transpileSchema } from '@middy/validator/transpile';
import { AjvSchema } from '@raminyavari/ajv-ts-schema';
export const middify = ({ handler, schema }: {
handler: unknown;
schema: typeof AjvSchema
}) => {
const ajvSchema = schema.getSchema();
const handlerWrapper = async (event: unknown, context: unknown) => {
const result = await (handler as LambdaHandler)(event, context);
return {
statusCode: 200,
body: typeof result === "string" ? result : JSON.stringify(result),
};
};
return middy()
.use(eventParser())
.use(validator({ eventSchema: transpileSchema(ajvSchema) }))
.use(errorHandler())
.handler(handlerWrapper);
};
A basic implementation of eventParser
:
const eventParser = () => {
return {
before: async (request: any) => {
request.event = parseJson(request.event);
},
};
};
const parseJson = (value: string | object) => {
try {
return typeof value === "string" ? JSON.parse(value) : value;
} catch {
return value;
}
};
Finally, the errorHandler
middleware:
import { normalizeHttpResponse } from "@middy/util";
const errorHandler = () => ({
onError: async (request: any) => {
if (request.response !== undefined) return;
normalizeHttpResponse(request);
const { statusCode, name } = request.error;
const message = (request.error.message as string) || "Internal Server Error";
const error = {
message,
extensions: {
exception: { code: name },
code: name,
},
};
request.response = {
...request.response,
statusCode,
body: JSON.stringify({
errors: [error],
}),
};
request.error = {
...request.error,
message: undefined,
expose: true,
};
},
});
@raminyavari/ajv-ts-schema